useEffect로 직접 데이터 가져오기의 함정과 TanStack Query가 최선의 선택인 이유
Olivia Novak
Dev Intern · Leapcell

서론
프론트엔드 개발의 역동적인 세계에서 비동기 작업, 특히 데이터 가져오기를 관리하는 것은 상호작용적이고 반응성이 뛰어난 사용자 인터페이스를 구축하는 데 핵심입니다. React의 useEffect 훅은 부수 효과를 처리하는 데 강력하지만, 데이터 가져오기에도 자주 사용됩니다. 그러나 면밀히 처리되지 않은 이 일반적인 접근 방식은 경쟁 조건, 과도한 리렌더링 및 복잡한 동기화 로직을 포함한 다양한 문제로 빠르게 이어질 수 있습니다. 이 글에서는 많은 개발자가 직면하는 일반적인 문제인 "useEffect 데이터 가져오기 안티패턴"을 자세히 살펴보고, TanStack Query(이전 React Query)와 같은 최신 데이터 가져오기 라이브러리를 사용하면 훨씬 더 우아하고 효율적이며 유지보수 가능한 솔루션을 제공한다고 주장합니다. 이러한 과제를 이해하고 더 나은 패턴을 채택하는 것은 단순히 미적인 선택이 아니라 애플리케이션 성능, 개발자 경험 및 장기적인 유지보수성에 직접적인 영향을 미칩니다.
useEffect를 통한 수동 데이터 가져오기의 문제점
TanStack Query의 "왜"를 자세히 살펴보기 전에 관련 핵심 개념과 문제를 공통적으로 이해해 보겠습니다.
핵심 용어
- 부수 효과(Side Effect): React에서 부수 효과는 컴포넌트 범위를 벗어나는 무언가에 영향을 미치는 모든 작업입니다. 예를 들어 데이터 가져오기, DOM 수동 변경, 구독 및 타이머가 있습니다.
useEffect는 이러한 작업을 처리하도록 설계되었습니다. - 경쟁 조건(Race Condition): 경쟁 조건은 두 개 이상의 작업(예: 데이터 가져오기)이 동시에 실행되고 그 결과가 완료되는 특정 순서에 따라 달라질 때 발생합니다. 순서가 관리되지 않으면 오래되거나 잘못된 상태가 표시될 수 있습니다.
- 오래된 데이터(Stale Data): 소스가 업데이트되었지만 클라이언트 측 표현이 업데이트되지 않아 더 이상 최신이거나 정확하지 않은 데이터입니다.
- 캐시 무효화(Cache Invalidation): 캐시된 데이터를 오래된 것으로 표시하여 신선한 데이터를 다시 가져오도록 강제하는 프로세스입니다.
- 쿼리 키(Query Key): TanStack Query에서 캐시의 특정 서버 상태 조각을 식별하는 데 사용되는 고유 식별자(일반적으로 배열)입니다.
useEffect 데이터 가져오기 안티패턴
일반적인 useEffect 데이터 가져오기 접근 방식과 내재된 문제를 설명해 보겠습니다.
게시물 목록을 가져오는 간단한 컴포넌트를 생각해 보겠습니다.
import React, { useState, useEffect } from 'react'; function PostList() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const fetchPosts = async () => { try { setLoading(true); const response = await fetch('https://api.example.com/posts'); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setPosts(data); } catch (e) { setError(e); } finally { setLoading(false); } }; fetchPosts(); }, []); // 빈 종속성 배열은 마운트 시 한 번 실행됨을 의미 if (loading) return <div>Loading posts...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <h1>Posts</h1> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); } export default PostList;
이것은 간단해 보이지만 검색 입력 필드를 추가해야 한다고 상상해 보세요.
import React, { useState, useEffect } from 'react'; function SearchablePostList() { const [posts, setPosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); useEffect(() => { // 정리 및 경쟁 조건에 대한 AbortController 추가 const abortController = new AbortController(); const signal = abortController.signal; const fetchPosts = async () => { try { setLoading(true); setError(null); // 이전 오류 지우기 const url = `https://api.example.com/posts?q=${searchTerm}`; const response = await fetch(url, { signal }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); setPosts(data); } catch (e) { if (e.name === 'AbortError') { console.log('Fetch aborted'); } else { setError(e); } } finally { setLoading(false); } }; // 더 나은 UX를 위해 검색 입력에 대한 디바운스 추가 const debounceTimeout = setTimeout(() => { fetchPosts(); }, 300); // 정리 함수 return () => { clearTimeout(debounceTimeout); abortController.abort(); // 마운트 해제 또는 종속성 변경 시 진행 중인 가져오기 중단 }; }, [searchTerm]); // searchTerm 변경 시 다시 실행 if (loading) return <div>Loading posts...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <input type="text" placeholder="Search posts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> <h1>Posts</h1> <ul> {posts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); }
코드가 얼마나 빨리 복잡해지는지 주목하세요:
- 상태 관리 오버헤드:
posts,loading,error상태를 수동으로 관리합니다. 데이터 가져오기 작업마다 이 보일러플레이트가 반복됩니다. - 경쟁 조건:
searchTerm이 빠르게 변경되면 여러fetch요청이 진행 중일 수 있습니다. 적절한 정리(예:AbortController)가 없으면 이전의 느린 요청이 최신이고 빠른 요청보다 나중에 완료되어 오래된 데이터가 표시될 수 있습니다. - 다시 가져오기 로직: 사용자가 다른 곳으로 이동했다가 다시 돌아오면 어떻게 될까요? 또는 서버에서 데이터가 오래되면 어떻게 될까요? 자동 다시 가져오기 또는 백그라운드 업데이트를 위한 기본 제공 메커니즘이 없습니다.
- 캐싱: 캐싱 메커니즘이 없습니다. 컴포넌트가 마운트되거나 종속성이 변경될 때마다 데이터는 처음부터 다시 가져옵니다. 이는 성능과 API 사용량에 영향을 미칩니다.
- 요청 중복 제거: 여러 컴포넌트가 동시에 동일한 데이터를 가져오려고 하면 모두 별도의 요청을 하게 됩니다.
- 오류 처리 및 재시도: 기본적인 오류 처리는 존재하지만, 실패 시 자동 재시도와 같은 고급 기능은 없습니다.
- 컴포넌트 간 동기화: 동일한 리소스를 가져오는 다른 컴포넌트 간에 데이터를 공유하는 것은 컨텍스트 또는 전역 상태 관리가 필요한 까다로운 일이 됩니다.
이 "안티패턴"은 useEffect가 본질적으로 나쁘다는 것이 아니라, useEffect가 서버 상태 캐시를 복잡한 수명 주기 관리 및 최적화와 함께 관리하는 데 올바른 도구가 아니라는 것입니다. useEffect는 가져오기를 시작할 수 있지만, 서버 상태의 전체 수명 주기를 직접 관리하는 기능은 부족합니다.
TanStack Query의 등장
TanStack Query(종종 React Query로 알려져 있음)는 React 애플리케이션에서 서버 상태를 관리, 캐싱 및 동기화하는 강력한 라이브러리입니다. fetch를 부수 효과로 취급하는 것에서 서버 상태로 데이터를 취급하는 패러다임으로 전환하며, 이는 자체 수명 주기와 UI 상태와는 별개의 관심사를 갖습니다.
TanStack Query를 사용한 SearchablePostList 컴포넌트는 다음과 같습니다.
import React, { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; // 설명용 간단한 인공 디바운스 훅 // 실제 앱에서는 'use-debounce'와 같은 라이브러리 또는 React의 useDeferredValue를 사용할 수 있습니다. function useDebounce(value, delay) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; } function SearchablePostListWithQuery() { const [searchTerm, setSearchTerm] = useState(''); const debouncedSearchTerm = useDebounce(searchTerm, 500); // 검색 입력 디바운스 const fetchPosts = async (queryKey) => { const [_key, { q }] = queryKey; // 쿼리 키 분해 const url = `https://api.example.com/posts?q=${q}`; const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response.json(); }; const { data: posts, isLoading, isError, error, isFetching // 현재 데이터가 가져와지고 있는지 나타냅니다 (백그라운드 다시 가져오기에 유용) } = useQuery({ queryKey: ['posts', { q: debouncedSearchTerm }], // 이 쿼리의 고유 키 queryFn: fetchPosts, staleTime: 5 * 60 * 1000, // 데이터는 5분 동안 신선한 것으로 간주됩니다. keepPreviousData: true, // 새 데이터가 가져와지는 동안 이전 데이터 표시 유지 }); if (isError) return <div>Error: {error.message}</div>; return ( <div> <input type="text" placeholder="Search posts..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> {isLoading && debouncedSearchTerm === '' ? ( // 검색어가 없을 때 초기 로딩 상태 <div>Loading posts...</div> ) : isFetching ? ( // 다시 가져올 때 다른 표시기 표시 <div>Searching for "{debouncedSearchTerm}"...</div> ) : null} <h1>Posts</h1> <ul> {posts?.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ); } export default SearchablePostListWithQuery;
여기서 장점을 자세히 살펴보겠습니다.
- 선언적 데이터 가져오기:
queryKey와queryFn을 사용하여useQuery를 선언합니다. TanStack Query가 "방법"을 처리합니다. - 자동 상태 관리:
isLoading,isError,data는 모두useQuery에서 제공합니다. 더 이상 수동useState는 필요하지 않습니다. - 캐싱 및 중복 제거: TanStack Query는
queryKey를 기반으로 데이터를 자동으로 캐싱합니다. 다른 컴포넌트가['posts', { q: 'react' }]를 캐시 중(그리고 오래되지 않음)에 가져오려고 하면 새 네트워크 요청 없이 즉시 캐시된 데이터를 가져옵니다. 동일한 키에 대한 보류 중인 요청도 중복 제거됩니다. - Stale-While-Revalidate: 기본적으로 TanStack Query는 "stale-while-revalidate" 캐싱 전략을 사용합니다. 백그라운드에서 신선한 데이터를 투명하게 가져오면서 즉시 오래된 데이터를 제공합니다. 이는 훌륭한 사용자 경험을 제공합니다.
staleTime옵션을 사용하면 데이터가 백그라운드 다시 가져오기 대상이 되기 전까지 데이터가 "신선한" 것으로 간주되는 시간을 구성할 수 있습니다. - 경쟁 조건 방지: TanStack Query는 최신 쿼리 함수 호출의 결과만 상태에 적용하여 순서가 잘못되어 해결될 수 있는 이전 프로미스를 효과적으로 중단함으로써 내부적으로 경쟁 조건을 처리합니다.
- 백그라운드 다시 가져오기: 데이터는 다음과 같은 경우 자동으로 다시 가져옵니다.
- 사용자가 인터넷에 다시 연결될 때.
- 창이 다시 포커스될 때.
- 쿼리 키가 변경될 때.
- 수동으로 다시 가져오기를 트리거할 때.
- 오류 처리 및 재시도: 쿼리 실패 시 지수 백오프를 사용한 내장 재시도 메커니즘.
keepPreviousData: 이 강력한 옵션은queryKey가 변경될 때(예:searchTerm업데이트) 새 데이터가 로드되는 동안 UI가 갑자기 비어 있는 것으로 번쩍이지 않도록 합니다. 새 데이터가 도착할 때까지 이전 데이터를 표시하여 원활하게 전환합니다.- Devtools: TanStack Query는 캐시, 쿼리 및 변이 보기를 위한 훌륭한 devtools와 함께 제공됩니다.
무엇을 언제 사용해야 할까요?
TanStack Query는 서버 상태에 이상적이지만, useEffect는 다른 부수 효과에 여전히 그 자리가 있습니다.
- UI 부수 효과를 위한
useEffect: DOM 조작, 이벤트 리스너 설정, 외부 저장소 구독(useContext가 충분하지 않은 전역 테마 컨텍스트와 같은) 또는 DOM에 직접 영향을 미치는 타사 라이브러리 통합. - 로컬 상태 동기화를 위한
useEffect: 로컬 컴포넌트 상태를 props 또는 다른 로컬 상태와 동기화(예: 항목 ID prop이 변경될 때 양식 필드 재설정).
useEffect는 일반 목적의 부수 효과를 표현하기 위한 저수준 기본 요소입니다. TanStack Query는 비동기 서버 상태의 복잡성을 처리하기 위해 특별히 설계된 고수준 추상화입니다.
결론
useEffect 데이터 가져오기 안티패턴은 서버 상태 관리라는 특수한 문제를 해결하기 위해 일반적인 부수 효과 훅을 사용하면서 발생합니다. 간단한 경우에는 작동하지만 캐싱, 동기화 및 경쟁 조건을 처리할 때 상당한 보일러플레이트와 복잡성을 초래합니다. TanStack Query는 서버 데이터를 선언적으로 관리하기 위한 강력하고 정해진 의견을 가진 고도로 최적화된 솔루션을 제공함으로써 이러한 문제로부터 개발자를 해방시킵니다. TanStack Query와 같은 라이브러리를 채택함으로써 애플리케이션을 더 높은 성능, 복원력 및 유지보수성으로 끌어올려 더 나은 개발자 및 사용자 경험을 제공할 수 있습니다. 이제 컴포넌트가 UI에 집중하고 전용 도구가 데이터를 관리하도록 할 때입니다.